Skip to content

ci: add a manual firestore flake probe for #776 - #780

Merged
tyler-reitz merged 2 commits into
FirebaseExtended:mainfrom
tyler-reitz:ci/flake-probe
Aug 7, 2026
Merged

ci: add a manual firestore flake probe for #776#780
tyler-reitz merged 2 commits into
FirebaseExtended:mainfrom
tyler-reitz:ci/flake-probe

Conversation

@tyler-reitz

@tyler-reitz tyler-reitz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Refs #776. This measures the flake; it does not fix it.

Why

Every measurement of the #776 flake so far has been on a laptop. Locally the failure is a plain waitFor timeout with no gRPC error at all. In CI it arrives alongside a gRPC framing desync:

RESOURCE_EXHAUSTED: Received message larger than max (795107700 vs 4194304)

A ~795 MB claimed frame against a 4 MB cap means the reader is interpreting payload bytes as a length header. That is a @grpc/grpc-js-layer failure, and it does not appear in any local run.

So the local repro and the CI failure may not be the same bug. That matters, because the @grpc/grpc-js override proposed as the fix was measured only against the local one, at p = 0.12. Merging that override would also mean CI never runs 1.9.16 again, so it is a before-and-after with no control.

This workflow makes the comparison in CI, where the failure actually happens, before anything is changed.

What it does

Matrixes Node 22 and 24. Within each job it runs both @grpc/grpc-js arms sequentially, baseline then override, N times per arm (default 30), and writes a table per arm to the job summary: resolved grpc-js version, pass / flake / hang / infra counts, how many runs showed RESOURCE_EXHAUSTED, how many flakes showed it too, and the resulting rate.

Design decisions worth reviewing:

  • workflow_dispatch only. It never runs on a push, a PR, a release or a schedule, so it adds nothing to normal CI time.
  • The arms are deliberately NOT a matrix dimension. The premise of the probe is that the machine matters, so putting the arms on separate runners would reintroduce the variable being measured. They run back to back on one runner instead, which costs a longer job. Arm order is forced baseline-then-override, because applying the override mutates node_modules for everything after it.
  • A fresh emulator per iteration, matching how npm test runs in CI. Reusing one emulator across iterations would measure something else.
  • Failures are classified, not counted. Only the CI: test/firestore.test.tsx flakes intermittently on both Node 22 and Node 24 #776 assertion signature counts toward the rate. Emulator start failures are reported separately, because folding them in is exactly what inflated the earlier local estimate by roughly 50% and made 30 runs look adequately powered when they were not.
  • Hangs get their own bucket. A test timeout produces no assertion line, so without this it would land in infra and drop out of the rate entirely. CI: test/firestore.test.tsx flakes intermittently on both Node 22 and Node 24 #776 reports a 120s hang.
  • Unrecognized failures are surfaced, not swallowed. The flake match is a literal vitest assertion string, so a reword would quietly turn every real flake into an infra failure. Anything unmatched has its assertion line collected and shown as a warning in the summary.
  • It reports rather than fails. A red job here means the probe broke, not that the flake reproduced. It exits non-zero only if every run failed for infrastructure reasons, meaning it measured nothing.
  • The summary renders in an always() step from counts persisted per arm, so a job killed by timeout-minutes still reports the arms that finished instead of losing every count.
  • RESOURCE_EXHAUSTED is counted per arm, and its overlap with the flake count is counted too. Two independent totals cannot say whether the gRPC desync and the CI: test/firestore.test.tsx flakes intermittently on both Node 22 and Node 24 #776 assertion co-occur, which is the question the probe exists for.
  • Inputs reach the script through env rather than being interpolated into the shell, and both iterations and arms are validated before they reach the loop.

⚠️ This probes one emulator and one test file. CI runs five emulators and the whole suite in parallel. RESOURCE_EXHAUSTED has never appeared in a firestore-only run, so this configuration may reproduce the local failure while never reaching the CI one. Every job summary repeats this, because a clean table here is not a verdict on CI.

Verification

⚠️ This workflow cannot be exercised from this PR. GitHub only offers workflow_dispatch for workflows present on the default branch, so it is unrunnable until this merges. Per the repo's own lesson about treating jobs authored on a branch as unrun, it was verified out of band instead:

  • The classifier was dry-run against synthetic logs covering pass, flake, flake-with-gRPC-error, hang, infra-failure and a reworded assertion. It returned the expected counts, excluded hangs and infra failures from the rate, and routed the reworded assertion to the unmatched warning rather than counting it. This is the part most worth getting right, since a probe that miscounts is worse than no probe.
  • The summary was rendered from a partial counts file, which is the path a job killed by timeout-minutes takes.
  • The arms input rejects empty, unknown and non-JSON values, and normalizes to baseline-first regardless of the order given.
  • zizmor 1.25.2 reports no findings beyond the cache-poisoning rule CI suppresses, which is the severity set the gate reads.
  • YAML parses; the package.json override edit produces the right key (npm pkg set mangles keys containing a slash, hence the node -e); npx firebase resolves through firebase-tools.

The first real run is therefore the first proof it works. If it misbehaves, the fix is another PR, not a rollback of anything consumers see.

Known confound: narrower than first stated

Corrected after review. This section originally warned that the repo-wide override moves @grpc/grpc-js for firebase-tools and google-gax as well as the Firestore client, so the arms differed by more than the library under test. A lockfile diff of the two arms shows that is not the case: exactly one version moves, @grpc/grpc-js 1.9.16 to 1.14.4. google-gax was already on 1.14.4, so its nested copy simply dedupes into the hoisted one. Nothing else budges.

So the arms differ by the Firestore client's grpc-js and nothing else, which is a cleaner comparison than the original caveat implied. Credit to @armando-navarro for diffing it rather than taking the caveat at face value.

What this changes for anyone else

Nothing. One new file, no existing workflow touched, no runtime or packaging change, and no effect on normal CI.

Every measurement of the FirebaseExtended#776 flake so far has been local, where the
failure is a plain waitFor timeout with no gRPC error. In CI it arrives
alongside a gRPC framing desync (RESOURCE_EXHAUSTED: Received message
larger than max), which raises the possibility that the local repro and
the CI failure are not the same bug. That matters, because the
@grpc/grpc-js override proposed as the fix was measured only against
the local one.

This runs the firestore suite N times per arm, across both Node
versions and both grpc-js versions, under CI conditions, so the
comparison happens where the failure actually occurs.

Notes on the design:

- workflow_dispatch only. It never runs on a push, a PR or a schedule,
  so it costs nothing until someone asks for it.
- A fresh emulator per iteration, matching how npm test runs in CI.
  Reusing one emulator across iterations would measure something else.
- Failures are classified, not counted. Only the FirebaseExtended#776 assertion
  signature counts toward the rate; emulator start failures are
  reported separately, because folding them in previously inflated a
  local rate estimate by roughly 50%.
- The job reports rather than fails. A red run here means the probe
  broke, not that the flake reproduced.
- Inputs reach the script through env rather than interpolation, and
  iterations is validated before it reaches the loop.

Classifier dry-run against synthetic logs covering pass, flake,
flake-with-gRPC-error and infra-failure returns the expected counts and
excludes infra failures from the rate. zizmor 1.25.2 reports no
findings beyond the cache-poisoning rule CI suppresses.

Refs FirebaseExtended#776

@armando-navarro armando-navarro left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this is worth having. Approving it. A few things I would think about before anyone runs it and reads numbers off the table.

The two arms land on different runners

arm is a matrix dimension, so baseline and override come out as separate jobs on separate machines. You were careful about this on your laptop, same machine and back to back, and that is the one control that did not make the trip. Given the whole premise here is that the machine matters, I would pull arm out of the matrix and loop both arms inside one job instead. Costs you a longer job, but then the comparison means something.

It is not running the workload the failures came from

CI does npm run test, so five emulators, the whole suite, parallel workers. This does one emulator and one file.

Your local runs were firestore-only too and still hit 6/44, so I am not saying it will fail to reproduce anything. But RESOURCE_EXHAUSTED has never turned up in a firestore-only run, and that is the signal you actually care about. What worries me is that you reproduce the laptop bug on CI hardware and the CI bug never shows up. Might be worth a line in the job summary so nobody reads a clean table as a verdict.

The RESOURCE_EXHAUSTED number is a total, not an overlap

grpc_err goes up before the run gets classified, so Flake: 2 sitting next to RESOURCE_EXHAUSTED: 11 does not tell you whether those 2 are inside the 11. That overlap is the whole question. One more counter in the flake branch and it is in the table.

Hangs fall out of the rate entirely

If Firestore calls never finish you get Error: Test timed out in 5000ms and no assertion line, so the run goes to infra and drops out of both halves of the rate. #776 mentions a 120s hang. Should those get their own bucket?

Related: the flake match is a literal assertion string, so if vitest ever rewords it, every real flake quietly becomes an infra failure.

On run counts

#776 calls 30 per arm underpowered at p = 0.12, and the default here is 20.

Separately, the guard lets you pass 200. At your 25s estimate that is 83 minutes against a 60 minute timeout. I clocked 7s locally so your estimate is probably pessimistic, but if it ever does hit the wall the summary block never runs and you lose every count.

Your confound is not actually there

I diffed the two lockfiles and exactly one version moves: @grpc/grpc-js, 1.9.16 to 1.14.4. google-gax was already on 1.14.4, so its nested copy just dedupes into the hoisted one. Nothing else budges. The arms differ by the Firestore client's grpc-js and nothing else, which is cleaner than your caveat makes it sound.

One last thing. I checked the classifier by actually breaking the test rather than trusting the grep on sight. Shortened the waitFor on returns undefined if document does not exist and ran your command against it. Your pattern catches it.

Six fixes from Armando's review on FirebaseExtended#780:

- Pull `arm` out of the matrix. Both arms now run sequentially in one
  job on one runner, so the machine is held constant. That was the
  control the local measurements had and the workflow dropped, and the
  premise of the probe is that the machine matters. Arm order is forced
  baseline-then-override because applying the override mutates
  node_modules for everything after it.
- Note in the job summary that this runs one emulator and one file,
  while CI runs five emulators and the whole suite in parallel, so a
  clean table is not a verdict on CI.
- Count the flake/RESOURCE_EXHAUSTED overlap. Two independent totals
  could not answer whether the gRPC desync and the FirebaseExtended#776 assertion
  co-occur, which is the question the probe exists for.
- Give hangs their own bucket. A test timeout produces no assertion
  line, so it was landing in `infra` and dropping out of the rate
  entirely. FirebaseExtended#776 reports a 120s hang.
- Persist counts per arm and render the summary in an `always()` step.
  A run that hits `timeout-minutes` now still reports the arms that
  finished, instead of losing every count. Default iterations 20 -> 30,
  matching FirebaseExtended#776's power note.
- Surface unrecognized failures. The flake match is a literal vitest
  assertion string, so a reword would have quietly turned every real
  flake into an infra failure. Unmatched assertion lines are now
  collected and shown as a warning in the summary.

Also drops a `set +e` / `set -e` pair that turned errexit on partway
through a script that never had it enabled.

Verified: classifier dry-run over synthetic logs covering pass, flake,
flake-with-gRPC, hang, infra and a reworded assertion returns the
expected counts and routes the reword to the unmatched warning; arms
input validation rejects empty, unknown and non-JSON values; the
summary renders correctly from a partial counts file, which is the
timeout-recovery path; zizmor 1.25.2 reports no findings beyond the
suppressed cache-poisoning rule.
@tyler-reitz

Copy link
Copy Markdown
Contributor Author

Thanks, all six are in as 43f39e5. Taking them in order.

The two arms landing on different runners. You're right and this is the one that mattered most: the premise is that the machine matters, so splitting the arms across runners reintroduces the variable being measured. arm is out of the matrix; both arms now run sequentially inside one job. Matrix is Node alone. Arm order is forced baseline-then-override regardless of what the input says, because applying the override mutates node_modules for everything after it, and I added a header comment saying why the arms are not a matrix dimension so it doesn't get put back.

Not running the workload the failures came from. Kept the scope, took the line in the summary. Every run now ends with a footer saying this is one emulator and one file against CI's five emulators and parallel suite, that RESOURCE_EXHAUSTED has never appeared in a firestore-only run, and that this configuration may reproduce the local failure while never reaching the CI one. A clean table is explicitly not a verdict.

The RESOURCE_EXHAUSTED total vs the overlap. Added a flake_with_grpc counter in the flake branch, rendered directly under the flake row as "...of which also showed RESOURCE_EXHAUSTED". Two independent totals could not answer the question the probe exists for.

Hangs falling out of the rate. They get their own bucket now, matching Test timed out in Nms and Hook timed out in Nms, reported and excluded rather than silently absorbed into infra. Your related point about the literal assertion string is the sharper one, so it's handled too: any failure the classifier does not recognize gets its assertion line captured and rendered as a warning block in the summary. If vitest rewords the message, it shows up as "the flake counts above are wrong" instead of quietly inflating infra. I verified that path with a synthetic log carrying strictly equal instead of deeply equal, and it lands in the warning rather than being counted.

Run counts. Default is 30 now. On the 200 guard: rather than lower the cap, counts are appended per arm to a probe-counts.tsv and the summary renders in a separate always() step, so a job killed at the wall still reports every arm that finished instead of losing everything. Tested against a partial counts file.

The confound. Thanks for diffing the lockfiles, that is better than the caveat I wrote. I'll narrow the "Known confound" section in the body to what you found: only @grpc/grpc-js moves, 1.9.16 to 1.14.4, and google-gax's nested copy dedupes into the hoisted one.

Also dropped a set +e / set -e pair around the test invocation that was turning errexit on partway through a script that never had it enabled. Latent, never fired, but it went with the restructure.

Re-verified after the changes rather than assuming: classifier dry-run over synthetic logs covering pass, flake, flake-with-gRPC, hang, infra and the reworded assertion returns the expected counts; the arms input rejects empty, unknown and non-JSON values; zizmor 1.25.2 still reports nothing beyond the suppressed cache-poisoning rule. The first real run is still the first proof of the whole thing, since workflow_dispatch isn't offered until this is on the default branch.

And noted on checking the classifier by actually breaking the test rather than reading the grep. That's the second time this week your verification caught something mine wouldn't have.

@tyler-reitz
tyler-reitz merged commit f3223ed into FirebaseExtended:main Aug 7, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants